09 — 调试 UI

Three.js Journey · Bruno Simon · 难度:简单

09 — 调试 UI 课程海报

介绍

每个创意项目的一个重要方面,就是能够轻松地调整它。开发者(也就是你)和参与项目的其他角色(比如设计师,甚至客户)必须能够修改尽可能多的参数。

你必须把这一点考虑进来,这样他们才能找到最完美的颜色、速度、数量等等,以获得最佳体验。你甚至可能偶然得到一些看起来非常棒的意外效果。

首先,我们需要一个调试 UI。

虽然你可以用 HTML / CSS / JS 创建自己的调试 UI,但已经有很多现成的库了:

这些都能做到我们想做的事,但我们会使用 lil-gui,因为它流行、持续维护,而且易于使用。

从 dat.GUI 到 lil-gui

最初,Three.js Journey 的所有练习都使用 dat.GUI。

有一段时间,这个库一直没有更新,NPM 在安装它时开始触发漏洞警告。那些漏洞后来被修复了,但替代品开始涌现,lil-gui 也因此作为 dat.GUI 的即插即用替代品越来越受欢迎。额外的收获是,它的功能甚至更好。

所有 Three.js Journey 练习现在都用 lil-gui,但你可能会在视频里看到对 dat.GUI 的引用。忽略它们,用一样的方式使用 lil-gui 就行。

顺便说一下,GUI 是 Graphical User Interface(图形用户界面)的缩写。

示例

你可以在我的作品集网站里找到一个相当不错的调试 UI 示例。这个 UI 只在你给 URL 加上 #debug 的时候才显示。

https://bruno-simon.com/#debug

你可以调整重力、颜色、速度、元素位置等等。

虽然创建所有这些调整项花了我大量时间,但没有它,游戏看起来就没那么平衡了。

准备工作

在 starter 里,我们有那个立方体,但依赖里不包含 lil-gui。我们会把它加进去,并创建一些调整项。

实例化 lil-gui

要把 lil-gui 加到我们的项目里,我们使用 Node.js 自带的依赖管理器 NPM(就像上一课为 GSAP 做的那样)。

在终端中(服务器未运行时,或者在同一个文件夹另开一个终端窗口)运行 npm install lil-gui

lil-gui 现在已经在 node_modules/ 文件夹里了,我们可以把它导入 script.js。别忘了重启服务器:

import './style.css'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import gsap from 'gsap'
import GUI from 'lil-gui'

// ...

现在你可以把 lil-gui 实例化到一个 gui 变量里,我们可以在最开头、紧接在导入之后做这件事:

/**
 * Debug
 */
const gui = new GUI()

在课程后面的部分,你可能会看到更多这样的写法:

import * as dat from 'lil-gui'

// ...

const gui = new dat.GUI()

它们本质上是一回事。用你喜欢的任何写法都行。

不同类型的调整项

在体验的右上角,你可以看到一个空面板。你可以给面板添加不同类型的调整项:

让我们看看其中的一些调整项。

Range

我们要添加的第一个调整项是范围。

大多数调整项都可以用 gui.add(...) 添加。第一个参数是对象,第二个参数是你要调整的那个对象的属性。

你需要在你创建了对象和它的属性之后,再添加 gui.add(...)。否则,你会让 lil-gui 去调整一个还不存在的东西。

让我们用 mesh.position.y 试试:

const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)

gui.add(mesh.position, 'y')
lesson demo video preview

面板里应该会出现一个输入框。试着改变它,然后观察立方体随之移动。

要指定最小值、最大值和精度,你可以在参数里设置它们:

gui.add(mesh.position, 'y', - 3, 3, 0.01)
lesson demo video preview

这样我们就得到了一个方便拖放的范围输入框。

如果你不喜欢有那么多参数,你可以在 add(...) 方法后面直接链式调用 min(...)、max(...) 和 step(...) 方法:

gui.add(mesh.position, 'y').min(- 3).max(3).step(0.01)

如果你不喜欢一行里链接太多方法,可以直接加换行:

gui
    .add(mesh.position, 'y')
    .min(- 3)
    .max(3)
    .step(0.01)

要修改标签,用 name(...) 方法:

gui
    .add(mesh.position, 'y')
    .min(- 3)
    .max(3)
    .step(0.01)
    .name('elevation')

针对非属性的调整项

这里有一件重要的事要注意:lil-gui 只能修改属性。如果你想更新一个变量,那是行不通的:

let myVariable = 1337
gui.add(myVariable, '???')

但你可以用一些技巧做到这一点,比如创建一个专门用来承载属性、供 lil-gui 在这个对象上使用的对象:

const myObject = {
    myVariable: 1337
}
gui.add(myObject, 'myVariable')

把这个调整项删掉,因为它只是个示例,我们稍后会在课程里实际用到这个技巧。

Checkbox

lil-gui 会自动检测你想调整的属性是什么类型,并采用对应的界面。一个很好的例子是 Object3D 的 visible 属性。它是一个布尔值,如果是 false,就会隐藏这个物体:

gui.add(mesh, 'visible')
lesson demo video preview

如你所见,因为 visible 属性是布尔值,lil-gui 选择了一个复选框。

我们可以对 material 的 wireframe 属性做同样的事:

gui.add(material, 'wireframe')
lesson demo video preview

Colors

处理颜色要稍微难一些。让我们试着修改 material 的 color 属性。

首先,我们需要用 addColor(...) 而不是 add(...),因为 color 属性既不是字符串、布尔值,也不是数字。它是带各种属性的对象,因为它是 Three.js Color 类的一个实例。在这些属性中有 r、g 和 b,lil-gui 可以用它们来显示漂亮的调整项:

gui.addColor(material, 'color')
lesson demo video preview

你的面板里应该会出现一个颜色选择器。

现在试着从调整项里取颜色值,把它应用到 color 属性上:

lesson demo video preview

你最后得到了错误的颜色:

这是因为 Three.js 应用了一些颜色管理来优化渲染。结果,调整项里显示的颜色值和内部使用的值并不相同。

我们现在不打算讨论颜色管理,把它留到以后的课程。但我们确实想要正确的颜色值。

有两种处理方式。

获取被修改后的颜色

第一种方案是,当调整项的值变化时,借助 Color 实例上的 getHexString() 方法,获取 Three.js 内部使用的颜色。

首先,我们需要感知到任何调整项的变化。为此,我们可以用 onChange() 方法:

gui
    .addColor(material, 'color')
    .onChange(() =>
    {
        console.log('value has changed')
    })
lesson demo video preview

接下来,我们需要访问 Color 实例,我们可以用常规的 material.color,或者直接把一个 value 作为函数的参数取出来:

gui
    .addColor(material, 'color')
    .onChange((value) =>
    {
        console.log(material.color)
        console.log(value)
    })
lesson demo video preview

两者是 material 的 color 属性里同一个 Color 实例。

让我们使用 value,并打印 getHexString() 的结果:

gui
    .addColor(material, 'color')
    .onChange((value) =>
    {
        console.log(value.getHexString())
    })

所以,这就是你可以在代码里放心使用的颜色值。

lesson demo video preview

这种技巧的问题在于,你需要打开 Console(控制台),这不太方便,尤其对设计师或客户来说。

只处理未经修改的颜色

第二种方案是,在颜色被 Three.js 修改之前就处理它。

首先,我们需要把颜色保存在 Three.js 之外的地方。我们要创建一个专门用于承载属性的对象。

我个人喜欢把它叫做 global、parameters 或 debugObject,并放在实例化 lil-gui 之后:

const gui = new GUI()
const debugObject = {}

然后,我们要给 debugObject 添加一个 color 属性,并且要在创建立方体之前做这件事,这样我们就可以立刻把它传给 MeshBasicMaterial 的 color 属性:

debugObject.color = '#3a6ea6'

const geometry = new THREE.BoxGeometry(1, 1, 1, 2, 2, 2)
const material = new THREE.MeshBasicMaterial({ color: debugObject.color, wireframe: false })

这样做,初始颜色就只存在于一个地方了。

然后,不修改之前我们写在 material.color 上的调整项,而是把它改成 debugObject.color:

gui
    .addColor(debugObject, 'color')
    .onChange(() =>
    {
        console.log(value.getHexString())
    })

最后,不做 console.log(),而是用 set() 方法更新 material.color:

gui
    .addColor(debugObject, 'color')
    .onChange(() =>
    {
        material.color.set(debugObject.color)
    })
lesson demo video preview

我喜欢用这个方案,这样没人需要打开 Console,但这也取决于你。

让我们把 debugObject.color 改回 #a778d8。

函数 / 按钮

有时候,我们只是想按需触发指令。现在,我们想让立方体在点击调试 UI 的某个地方时表演一个小旋转动画。

我们可以通过把一个包含函数的属性传给调整项来实现。遗憾的是,这意味着我们不能让函数单独摆在那里再传给 lil-gui:

const myFunction = () => {
    console.log('do something')
}
gui.add(myFunction, '???')

但我们可以在之前创建的 debugObject 对象上加一个 spin 属性,并把 GSAP 动画整合进去:

debugObject.spin = () =>
{
    gsap.to(mesh.rotation, { duration: 1, y: mesh.rotation.y + Math.PI * 2 })
}

最后,我们可以把调整项加到 debugObject.spin 上:

debugObject.spin = () =>
{
    // ...
}
gui.add(debugObject, 'spin')
lesson demo video preview

你应该会看到一个 spin 按钮,点击它就会让立方体转一整圈 360°。

调整几何体

我们为什么不试着调整一下几何体的细分呢?

首先,我们把 wireframe 设为 true,以便可视化那些三角形:

const material = new THREE.MeshBasicMaterial({ color: '#9c7fe3', wireframe: true })

如果你查看 BoxGeometry 文档,你会看到控制细分的参数分别叫 widthSegments、heightSegments 和 depthSegments。

让我们试着给 geometry.widthSegments 添加一个调整项:

gui
    .add(geometry, 'widthSegments')
    .min(1)
    .max(20)
    .step(1)

我们报错是因为 widthSegments 不是 geometry 的属性。

widthSegments 只是我们在实例化 BoxGeometry 时传给它的参数。它只被用来生成一次整个几何体。

首先,因为它不是实际属性,我们需要给 debugObject 对象添加一个 subdivision 属性,并把我们的调整项应用到它上面:

debugObject.subdivision = 2
gui
    .add(debugObject, 'subdivision')
    .min(1)
    .max(20)
    .step(1)
lesson demo video preview

我们把它命名为 subdivision,这样我们就把它用于 widthSegments、heightSegments 和 depthSegments 这三个参数。

接下来,当调整项的值变化时,我们要销毁旧的几何体并构建一个全新的。

为此,我们首先要监听调整项里的 onChange 事件:

gui
    .add(debugObject, 'subdivision')
    .min(1)
    .max(20)
    .step(1)
    .onChange(() =>
    {
        console.log('subdivision changed')
    })
lesson demo video preview

构建几何体对 CPU 来说可能是个相当漫长的过程。现在,我们在监听 change 事件,如果用户过多地拖放范围调整项,它可能会被触发很多次。

不用 onChange,我们改用 onFinishChange,它只会在我们停止调整值时触发一次:

gui
    .add(debugObject, 'subdivision')
    .min(1)
    .max(20)
    .step(1)
    .onFinishChange(() =>
    {
        console.log('subdivision finished changing')
    })
lesson demo video preview

不写那个 console.log(),我们可以用 debugObject.subdivision 构建一个新的几何体,并通过把它赋值给 mesh 的 geometry 属性来与 mesh 关联:

gui
    .add(debugObject, 'subdivision')
    .min(1)
    .max(20)
    .step(1)
    .onFinishChange(() =>
    {
        mesh.geometry = new THREE.BoxGeometry(
            1, 1, 1,
            debugObject.subdivision, debugObject.subdivision, debugObject.subdivision
        )
    })
lesson demo video preview

这就可以了,但我们犯了一个小错误。旧的几何体仍然残留在 GPU 内存的某个地方,这可能会造成内存泄漏。

要修复它,在创建新几何体之前,我们可以对旧几何体调用 dispose() 方法:

gui
    .add(debugObject, 'subdivision')
    .min(1)
    .max(20)
    .step(1)
    .onFinishChange(() =>
    {
        mesh.geometry.dispose()
        mesh.geometry = new THREE.BoxGeometry(
            1, 1, 1,
            debugObject.subdivision, debugObject.subdivision, debugObject.subdivision
        )
    })

文件夹

假设我们的调整项有很多,调试 UI 开始变得拥挤。我们可以用 addFolder() 方法把它们分到文件夹里。

要创建一个文件夹,调用 addFolder() 并把你想要的名字作为参数传进去。确保在调整项之前创建它,并把它保存为 cubeTweaks:

const cubeTweaks = gui.addFolder('Awesome cube')
lesson demo video preview

然后,不通过 gui 创建调整项,而是使用 cubeTweaks 变量:

const cubeTweaks = gui.addFolder('Awesome cube')

cubeTweaks
    .add(mesh.position, 'y')
    // ...

cubeTweaks
    .add(mesh, 'visible')

cubeTweaks
    .add(material, 'wireframe')

cubeTweaks
    .addColor(material, 'color')
    // ...

// ...
cubeTweaks
    .add(debugObject, 'spin')

// ...
cubeTweaks
    .add(debugObject, 'subdivision')
    // ...
lesson demo video preview

你可以用 close() 方法让它默认折叠:

const cubeTweaks = gui.addFolder('Awesome cube')
cubeTweaks.close()

虽然我们不会这么做,但你可以在文件夹里嵌套文件夹。调试 UI 凌乱可没有借口!

让我们注释掉 close():

// cubeTweaks.close()

GUI 设置

lil-gui 很灵活,我们会看一些参数、方法和技巧,把它的潜力发挥到极致。

宽度

你可以通过给 GUI 构造函数传一个带 width 属性的对象来控制宽度:

const gui = new GUI({
    width: 300
})

标题

你可以用 title 属性修改面板顶部的标题:

const gui = new GUI({
    width: 300,
    title: 'Nice debug UI'
})

默认折叠文件夹

你可以用 closeFolders 让所有文件夹默认折叠:

const gui = new GUI({
    width: 300,
    title: 'Nice debug UI',
    closeFolders: true
})

让我们把它改回 false:

const gui = new GUI({
    width: 300,
    title: 'Nice debug UI',
    closeFolders: false
})

关闭

你可以调用 close() 方法关闭它:

const gui = new GUI({
    width: 300,
    title: 'Nice debug UI',
    closeFolders: false,
})
gui.close()
lesson demo video preview

注释掉 close():

// gui.close()

隐藏

你可以调用 hide() 方法把它完全隐藏:

const gui = new GUI({
    width: 300,
    title: 'Nice debug UI',
    closeFolders: false,
})
// gui.close()
gui.hide()

切换显示

但我们怎么让它再次显示出来?这取决于你,但一个快速的方案可以是监听 keydown 事件,如果是 h 键,我们就根据 _hidden 属性(标明它当前是否隐藏的布尔值)来切换显示:

window.addEventListener('keydown', (event) =>
{
    if(event.key == 'h')
        gui.show(gui._hidden)
})
lesson demo video preview

注释掉 hide(),让它默认可见:

// gui.hide()

更多

好了,这就是全部。但如果你好奇,可以看看 lil-gui 文档,了解更多设置和功能,比如:

结语

在接下来的练习中,我们会在特定时刻使用调试 UI。不过,你想加多少调整项就加多少。这是练习并开始创作东西的好方式。

我建议你随着进度不断添加调整项。如果你打算在项目结束时一次性加完所有调整项,你很可能一个都不会加,还会错过许多机会。

记住,下面有些课程是用 dat.GUI 而不是 lil-gui 录制的。差别不大,你应该能毫无障碍地跟上。